iT邦幫忙

2022 iThome 鐵人賽

DAY 26
0
Modern Web

Three.js 學習日誌系列 第 26

Day25 - 打造質感系3D聊天室 - three.js + socket.io (三)

  • 分享至 

  • xImage
  •  

Day25 - 打造質感系3D聊天室 - three.js + socket.io (三)

這裡是「Three.js學習日誌」的第25篇,這篇是在講解使用three.js + socket.io打造3D聊天室作品。這系列的文章假設讀者看得懂javascript,並且有Canvas 2D Context的相關知識。

今天我們的目標是要來完成時鐘面板還有兩個地方的聊天室面板~

當然還有socket.io的連線機制

話不多說~就讓我們先來看看今天的完成狀況吧~

img

這邊我還順便補了一個能夠用來停止方塊旋轉的按鈕

1. 首先來看看停止方塊旋轉按鈕的實作

筆者昨天在實際試用的時候發覺...

果然~還是需要有一個停止方塊運動的手段,畢竟這玩意一直自旋,想要看到某一畫面還得一直動手去轉,真的很煩XD。

謎:明明是你自己設計的。

所以我後來還是決定加上一個禁止自旋的按鈕~

img

不過,因為我們在程式架構上一開始的設計是讓./src/ts/main.ts 底下的main類別去extends base類別,而domCubeCube又是去偵測base底下的屬性(touched)來決定要不要停止旋轉的。

所以這邊的作法就必須要像下面這樣。

img

我們在base類別的外面宣告一個變數,用來存放禁止方塊旋轉的狀態。

img

然後在base裡面建立get/set取向的方法,這樣rotationlocaked就不會被子類別繼承到。

接著就可以在domCubeCube裡面去用base提供的get/set方法來偵測禁止方塊旋轉的狀態。

img

看起來很簡單吧~

2. 來個可愛的Space Inavader

我們目前還有2個空白的方塊面板沒有拿來用,所以我打算拿其中一個來放一隻吉祥物XD。

img

剩下一面明天再來思考要放些什麼好了XD

這邊動畫的實作我其實不是使用canvas。而是直接使用CSS @keyframe

img

簡單來說原理就是利用CSS @keyframe去動態改變background-image,然後把所有的動畫幀都用Photoshop輸出成PNG圖串,每過幾毫秒就變動一次background-image

其實就是很簡單的特效,不過其實蠻不錯的吧?

3. 實作時鐘的部分

因為我們之前的時鐘其實還一直是只有假字串的狀態,今天把這部分處理完了,雖然這部分其實蠻簡單的,不過還是來看一下大概是怎麼處理~

img

簡單來說就是在./src/ts/dom/clock.ts底下建立渲染畫面的方法,還有setInterval的計時器~

4. 聊天室部分

最後就是聊天室的部分了~!

首先我感覺應該還是有必要來介紹一下Socket.io的用途,還有這部分我在架構上的規劃。

4-1 什麼是socket.io? 他跟Websocket有什麼關係?

socket.ioWebsocket的關係有一點點像jqueryjavascript,也就是包裝庫的概念。

4-1-a 那Websocket又是什麼呢?

Websocket其實是一種通訊協定,就像我們一般瀏覽網頁用的HTTP協定一樣。

但差別在於HTTP的連線機制是:

  • 會在每次和伺服器互動的時候,建立「請求(request)」

  • 伺服器確認這個請求之後,就會「回應(response)」對應的資料

Websocket的機制則是:

  • 只要在初期跟伺服器互動的時候,作出一次「交握(connection)」,接下來客戶端和伺服器端就可以自由的互相傳遞資料。

img

當我們在瀏覽器的Console介面打上Websocket這個字的時候,我們其實可以看到瀏覽器環境底下是具備這個同名的函數的。

img

而這個函數的用途其實只是用來實作Websocket程序的客戶端部分

// Create WebSocket connection.

const socket = new WebSocket('ws://localhost:8080');

// Connection opened
socket.addEventListener('open', (event) => {
    socket.send('Hello Server!');
});

// Listen for messages
socket.addEventListener('message', (event) => {
    console.log('Message from server ', event.data);
});

以上面這張圖片來說,我們可以透過該函數建立客戶端的Websocket實例,並把該實例的伺服器位址指向localhost:8080

這樣做意思就是說在8080上面其實已經有一台架設好的websocket server,而我們這邊只是引導客戶端跟這台Server執行「交握」

交握完畢之後,我們在客戶端就會收到Websocket伺服器傳來的信號。

就有點像我們前面提到過的EventEmitter。

這樣我們就可以決定要在什麼信號發生的時候去對前端畫面做什麼事。

而當然我們也可以從客戶端去發送訊息給websocket server,像是下面這樣。

socket.send(data)

4-1-b Websocket伺服器看起來像什麼樣子?

所謂的伺服器其實就是一台電腦,上面搭載的一段程序。

所以要建立伺服器不一定要使用另外一台電腦,通常測試開發階段我們都是在本機同時run websocket serverwebsocket client端

而如果要編寫websocket server的程序,其實有很多種電腦語言都可以辦到,但我們這邊當然就是要選用前端工程師比較常聽到的node.js

4-1-c 使用socket.io 和使用Websocket的差別在哪?

首先當然就是效率

在正常的使用狀況下,想要跟Websocket伺服器互動還是有分很多的場景,例如多方發送/廣播這種狀況, 使用Websocket就要自己重新造輪子

因為Websocket就只有指定對象的收跟發,如果要指定對全場成員發送,那就要自己寫出這樣的功能。

4-2 架構上的規劃

img

我自己的習慣是如果專案的規模不會很大,那就在同一個Repo底下開一個 server程序 的資料夾,這邊我把它命名為chat

chat底下會是一個獨立的module,所以在建立的時候我們必須要先:

cd chat && npm init

接著安裝socket.io的server端 npm 包。

npm i socket.io

另外我們需要有一個主程序,所以我建立了一個index.ts。建立完之後其實我們已經可以用node.js去執行這支index.ts,就像下面這樣。

node index.ts

不過,因為像這樣的執行方法,其實不會偵測index.ts本身的改動,並發動重載。所以我們這邊要改用nodemon來執行這個主程序。

nodemon 就有點像是給node.js程序用的live server

這邊先來安裝nodemon

npm i nodemon 

然後就可以用nodemon來執行主程序(npx 代表的是直接使用專案裡的package,不使用全域package的意思)

npx nodemon index.ts

也可以直接把這段直接寫在package.jsonscript裡面。

要注意到這邊為止我們都還是位於./chat底下唷。

4-2-a socket.io伺服器主程序

接下來就是主程序index.ts的部分。

這邊我其實是參考Jia-yun Yang 寫的 「用 Socket.IO 打造多人聊天室」這篇文章,然後自己修改成TS版本。

./chat/index.ts

import { createServer } from "http";
import { Server } from "socket.io";

const app = createServer()

//由於
const io = new Server(app, {
    cors: {
        origin: "http://192.168.1.101:8080", //這是我自己的IP
        methods: ["GET", "POST"]
    }
})
/*自訂監聽端口*/
const port = 5500;
app.listen(port);

console.log('app listen at ' + port)


/*用戶陣列*/
const users: { username: string }[] = [];

// 交握
io.on('connection', (socket) => {
    /*是否為新用戶*/
    let isNewPerson = true;
    /*當前登入用戶*/
    let username= null;

    //監聽登入
    socket.on('login', (data) => {
        for (var i = 0; i < users.length; i++) {
            isNewPerson = (users[i].username === data.username) ? false : true;
        }
        if (isNewPerson) {
            username = data.username
            users.push({
                username: data.username,
                id: socket.id
            })
            data.userCount = users.length
            data.users = users;
            /*發送 登入成功 事件*/
            socket.emit('loginSuccess', data)
            /*向所有連接的用戶廣播 add 事件*/
            io.sockets.emit('add', data);
        } else {
            /*發送 登入失敗 事件*/
            socket.emit('loginFail', '');
            socket.disconnect();
        }
    })

    //監聽登出
    socket.on('logout', (data) => {
        /* 發送 離開成功 事件 */

        socket.emit('leaveSuccess')
        /* 向所有連接的用戶廣播 有人登出 */
        users = users.filter((val) => {
            return (val.username !== data.username)
        })
        io.sockets.emit('leave', { username: data.username, userCount: users.length,users:users });
        socket.disconnect();
    })

    socket.on('disconnect', () => {
        socket.emit('leaveSuccess')

        const userLeft = users.filter((val) => {
            return (val.id === socket.id)
        })[0]?.username

        users = users.filter((val) => {
            return (val.id !== socket.id)
        })

        io.sockets.emit('leave', { username: userLeft, userCount: users.length,users:users })
    })

    socket.on('sendMessage', function (data) {
        /*發送receiveMessage事件*/
        io.sockets.emit('receiveMessage', data)
    })
})

上面的程式碼,筆者因為有發現錯誤,所以在2022.10.16進行過修改,如對觀眾朋友們造成困擾,深感抱歉。

值得一提的是在socket.io v3版本之後,我們會需要在伺服器端上建立CORS的白名單。

如果不懂什麼是CORS可以看這邊

const io = new Server(app, {
    cors: {
        //http://192.168.1.101:8080是我自己當前的區網IP,
        //我把server架設在http://192.168.1.101:5500
        // 客戶端的port則是調整了webpack的host設定,定在了8080
        origin: "http://192.168.1.101:8080", 
        methods: ["GET", "POST"]
    }
})

白名單的意思也就是說「不會去排除特定網域的客戶端交握行為」。如果我們剛剛在上面沒有設定cors這個屬性,那在我們按下登入按鈕的時候就會出現下面這個錯誤。

img

4-2-b socket.io客戶端

最後就是客戶端的部分了。

我目前是先把socket.io客戶端的程序放在 ./src/ts/main.ts裡面,這邊我們來看看都寫了些什麼~

import { Base } from './class/base';
import { io, Socket } from 'socket.io-client';
import { trim } from 'lodash';
class Main extends Base {
    // 個人習慣把抓取的元素都放置在一個區塊的最上面,這樣才方便找。
    // 原則上我們應該要避免一直去query元素,因為會對程式效能帶來影響。
    private wrapper: Element = document.querySelector('#wrapper');
    private chatBlock: Element = document.querySelector('#chat-block');
    private chatBlockActive = false;
    private socket: Socket = io('ws://192.168.1.101:5500');
    private myName: string;
    constructor(canvas: HTMLCanvasElement, domCanvas: HTMLElement, domBundle: HTMLElement) {
        super(canvas, domCanvas, domBundle);
        this.initChatUI();
        this.initChatSocket();
    }
    // ui操作後發動的事件綁定
    private initChatUI() {
        const toggler = this.chatBlock.querySelector('#chat-block-toggler');
        const rotationLock = this.chatBlock.querySelector('#rotation-lock');
        const loginBtn = this.chatBlock.querySelector('#login-button');
        const sendBtn = this.chatBlock.querySelector('#send-message-button');
        const logoutBtn = this.chatBlock.querySelector('#logout-button');
        toggler.addEventListener('click', () => {
            if (this.chatBlockActive) {
                this.wrapper.classList.remove('wrapper--active');
            }
            else {
                this.wrapper.classList.add('wrapper--active');
            }
            this.chatBlockActive = !this.chatBlockActive;
        })
        // 這邊是禁止旋轉按鈕的事件綁定
        rotationLock.addEventListener('click', () => {
            const status = this.getRotationLockStatus();
            if (status) {
                rotationLock.classList.remove('chat-block__rotation-lock--active');
            }
            else {
                rotationLock.classList.add('chat-block__rotation-lock--active');
            }

            this.toggleRotationLock(!status)

        })
        //登入按鈕的事件綁定
        loginBtn.addEventListener('click', () => {
            this.myName = trim((this.chatBlock.querySelector('#login-name') as HTMLInputElement).value);
            if (this.myName) {
                /*發送事件*/
                this.socket.emit('login', { username: this.myName })
            } else {
                alert('Please enter a name :)')
            }
        })
        //送信按鈕的事件綁定
        sendBtn.addEventListener('click', () => {
            this.sendMessage();
        })
       // 登出按鈕的事件綁定
        logoutBtn.addEventListener('click', () => {
            let leave = confirm('Are you sure you want to leave?')
            if (leave) {
                /*觸發 logout 事件*/
                this.socket.emit('logout', { username: this.myName });
            }
        })
        // 當用戶按下enter的時候要送信
        document.addEventListener('keydown', (evt: KeyboardEvent) => {
            if (evt.keyCode == 13) {
                this.sendMessage()
            }
        })


    }
    private initChatSocket() {
        /*登入成功*/
        //當收到socket的登入成功信號
        this.socket.on('loginSuccess', (data) => {
            if (data.username === this.myName) {
                this.checkIn(data)
            } else {
                alert('Wrong username:( Please try again!')
            }
        })

        /*登入失敗*/
        //當收到socket的登入失敗信號
        this.socket.on('loginFail', () => {
            alert('Duplicate name already exists:0')
        })

        /*加入聊天室提示*/
        //當收到socket的有其他人登入成功信號
        this.socket.on('add', (data) => {
            var html = `<p>${data.username} 加入聊天室</p>`
            // $('.chat-con').append(html);
            document.getElementById('chat-title').innerHTML = `在線人數: ${data.userCount}`
        })
        
        //離開成功
         //當收到socket的離開成功信號
        this.socket.on('leaveSuccess', () => {
            this.checkOut()
        })

        //退出提示
        //當收到socket的有其他人離開成功的信號
        this.socket.on('leave', (data) => {
            if (data.username != null) {
                let html = `<p>${data.username} 退出聊天室</p>`;
                // $('.chat-con').append(html);
                // document.getElementById('chat-title').innerHTML = `在線人數: ${data.userCount}`;
            }
        })
        
        //收到訊息
         //當收到socket的有人發送訊息信號
        this.socket.on('receiveMessage', (data) => {

            this.showMessage(data)
        })


    }
    private checkIn(data: any) {
        const loginWrapper = this.chatBlock.querySelector('#login');
        const userNameEle = this.chatBlock.querySelector('#my-name');
        userNameEle.innerHTML = data.username;
        loginWrapper.classList.add('login--logined');
    }

    private checkOut() {
        const loginWrapper = this.chatBlock.querySelector('#login');
        loginWrapper.classList.remove('login--logined');
    }

    private sendMessage() {
        const inputEle = this.chatBlock.querySelector('#message-input');
        const message = (inputEle as HTMLInputElement).value;
        (inputEle as HTMLInputElement).value = ''
        if (message) {
            /*觸發 sendMessage 事件*/
            this.socket.emit('sendMessage', { username: this.myName, message: message });
        }
    }
     // 把訊息內容渲染出來
    private showMessage(data: any) {
        let html;
        if (data.username === this.myName) {
            html = `<div class="chat-main__chat ">
                        <div class="chat-main__bubble-name">You</div>
                        <div class="chat-main__bubble">${data.message}</div>
                    </div>
                    `;
        } else {
            html = `<div class="chat-main__chat chat-main__chat--other">
                        <div class="chat-main__bubble-name">${data.username}</div>
                        <div class="chat-main__bubble">${data.message}</div>
                    </div>
                    `;
        }
        const ele = this.createElementFromHTML(html)
        this.chatBlock.querySelector('#chat-main').appendChild(ele);
        this.wrapper.querySelector('#chat-main-cube').appendChild(ele.cloneNode(true));
    }
    // 從字串產生html元素
    private createElementFromHTML(htmlString: string) {
        const div = document.createElement('div');
        div.innerHTML = htmlString.trim();

        // Change this to div.childNodes to support multiple top-level nodes.
        return div.firstChild;
    }

}

基本上我這邊的區塊劃分就是把「UI操作之後綁定的動作」,和「socket偵測到某特定信號之後的操作」個別分成一類。

筆者個人覺得把特定形式的程式碼分在同一個方法會相對地比較好整理。

小結

我們這個作品已經接近完成了~ 大概明天就會是這個部分的最後一篇,再麻煩各位持續追蹤~


上一篇
Day24 - 打造質感系3D聊天室 - three.js + socket.io (二)
下一篇
Day26 - 打造質感系3D聊天室- 部屬Websocket專案到Fly.io - three.js + socket.io(四)
系列文
Three.js 學習日誌31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言